Phase 5 — CSS Foundation and Viewport

Arc Post-Mortem Summary. This arc rebuilt the stylesheets' foundation and made pages answer the width they are given. It named the repeated colors, lengths, and control heights as custom properties (I), said lengths in rem and clamp rather than inches (II), and used logical direction properties so right-to-left falls out for free (III). It moved the phone-versus-desktop split from a user-agent guess into width-based media queries and added a short-height media query so a phone in landscape gets the compact layout too (IV), and added a built-in light/dark mode following the visitor's preference along with reduced-motion and focus-visible accessibility handling (V). It closed the interface fixes handed over from Phase 4.5 — the resource toolbar's uneven control heights, its layout on a phone, and the Add Search dialog (VI). Alongside the planned work it fixed a run of live-site issues: a large-folder listing timeout, a wiki title, several mail-server out-of-memory paths, a served-static-folder whole-file read, and the static-folder directory-index links, and it added a memory-fatal log that names the request the always-on server dies on. Two follow-ups were settled as not worth doing: an automated accessibility checker beside the existing one, and a visitor-facing theme picker.

Arc started 2026-07-17, after Phase 4.5. Sections are Roman numerals and steps within them are lettered; each step's title says what it does, followed by a sentence on what was proposed and, once worked, a few on what was done and anything learned. Marker legend: being worked on, implemented and waiting to be reviewed, done, closed by deciding not to do it, ? open decision, unmarked = planned.

What this arc is for

Yioop decides how it looks in two places at once. The server reads the user agent and branches on $_SERVER["MOBILE"] in 101 places across 45 files, sending different markup to a phone; the stylesheets then branch again on a .mobile class in 290 places. A page therefore cannot answer the width it is actually given, only the width the server guessed from a name. The viewport meta tag that would let a phone say how wide it is is emitted only when the server has already decided the reader is on one (WebLayout.php:100), because emitting it always was tried and reverted: the layout is sized in inches and points and runs off the side of a phone when the tag is honoured.

So the units come first, then the layout can flex, then the tag can be told the truth, and then the branches can go. That order is why item 25 was moved out of Phase 0 and why it sits at the end here rather than the start. The measurements below were taken on 2026-07-17 at the start of the arc; the master to-do's figures predate several arcs and read lower.

Whatsearch.cssall css
lengths in in309340
lengths in pt138169
lengths in px11711736
lengths in rem or em32 33
.html-ltr / .html-rtl selectors 110124
logical properties already used18
.mobile selectors290
custom properties defined00
distinct hex colors111

I. Name the things the stylesheets repeat

Master to-do item 26. Foundational: items 9, 17, 18 consume these, and sections II and IV below cannot be done tidily without them.

  1. A. Gather the colors into custom properties. Name the palette on :root and replace the literals. Four parts:
    1. The four a site sets for itself were written into every page's head along with the selectors using them. Those rules now live in search.css and a page sends only the values, cutting its style block from 1418 bytes to 247.
    2. The grays became a ladder from --gray-0 to --gray-100, numbered by lightness, replacing 132 literals in search.css.
    3. The named colors followed: 175 uses of black, white, gray and their kin in search.css, and 48 more with 139 hex literals across mail.css, messages.css, frise.css and editor.css, which read the same ladder.
    4. The hued colors stay literals: 43 of them over 60 uses, nearly all one-offs, and they want naming as roles — error, link, success — which is a design decision rather than a rounding.
    A rung was kept for every neutral used three or more times, so almost nothing moved: the largest shift anywhere is 7 of 255, and tinted grays such as the #e9e9ed button face stayed literals rather than being flattened, which a pixel comparison caught a first attempt doing.
  2. B. Name the spacing and the type sizes. So that section II has something to convert the inches and points into rather than converting each one by hand. Two parts, and only one of them was worth doing:
    1. The type sizes are named: --font-size-1 through --font-size-7, smallest first, with --font-size-base naming the 12pt that body text is set in and that a browser would have chosen anyway. 160 declarations across the five stylesheets now read them, leaving 19 rare sizes as literals. Six pages render identically.
    2. The spacing is not named, because there is no scale there to name. Margins and paddings use 1, 2, 3, 4, 5, 6, 8, 10 and 12 pixels, and the odd steps are not strays: 3px is used 53 times and 5px 69. A --space-1: 1px would rename a number to a number, and snapping the odd steps onto an even ladder would move 122 pieces of layout for tidiness alone.
    Naming the type sizes is what pays: saying type in a unit a screen understands rather than a printer is now a change to seven lines in :root instead of 160 declarations, which is the whole reason this section comes before section II.
  3. C. Name the control height the toolbars share. The 32-pixel height of an icon button was written out eight times in search.css and is why a row of controls that ought to line up does not. --control-height now names it: how tall a thing you can press or type into stands, an icon button, a select, a search field. The eight are the icon button and its container, the button group, the filter field on a phone once it has focus, and four of the git issue controls; the one 32px left is a glyph's size, which is not a control. Six pages render identically, and section VI.A can now say what it means when it makes the resource toolbar one row of one height.

II. Say lengths the way a screen understands them

Master to-do item 29. A phone cannot honour an inch; this is what made the viewport meta tag unusable.

  1. A. Convert the inches and points to rem, em, %, ch and vw. Every inch across the stylesheets is now rem, at six rem to the inch, which is an inch's ninety-six pixels over a rem's sixteen: 309 in search.css and 31 more in the editor, frise, mail and messages sheets. That ratio keeps the rendered size exactly as it was at the default type size while letting it grow when a reader enlarges their text, which is the point of the change; six pages render to the pixel. The points went with the type scale in I.B, save the scale's own defining values in :root, which stay points because that is where the sizes are set. The wide page-content widths are in rem like the rest; whether some should instead be a share of the viewport is II.C's question, left open there rather than pre-empted here.
  2. B. Let the sizes that should breathe use clamp(). Two things now grow with a wide screen and hold still on a normal one, bounded so neither runs away:
    1. The seven type sizes. Each is a clamp() of its present size as a floor, a middle that grows once the viewport passes an onset of 1100px, and a ceiling a few points above. Body text is 16px at 1100px and below, 17.7px at 1440px, and 20px from 1920px up.
    2. The page-content columns — body, search, and activity. Each keeps its designed width up to the onset and may grow toward the viewport beyond it, capped: the body column is 57rem to 1100px, about 67rem at 1440px, and 72rem from 1920px up.
    Two named tokens carry the shape, --fluid-onset (the 1100px width below which nothing changes) and --fluid-rate (how fast a size grows past it). At and below the onset every value equals what it was, so a normal window is untouched; the growth is on wider screens. This is the first Phase 5 change that alters rendering by design rather than holding it identical, so the static renderer here cannot check it — wkhtmltoimage does not resolve vw against a set width — and the sizes were verified by arithmetic across viewports rather than by pixel diff. It wants a browser's eye at a few widths.
  3. C. Decide what to do about the fixed pixel lengths. A pixel is a screen unit and is not wrong the way an inch is, so the rule Chris settled was to keep what draws a line and convert what holds space:
    1. Borders, border widths, border radii, and box shadows stay in px: they draw the interface's lines and edges, which should not thicken when a reader enlarges their text.
    2. Padding, margin, gap, width, height, and the positioning offsets of three pixels or more become rem at sixteen pixels to the rem, so the space around and between things grows with the reader's type. 799 lengths in search.css moved this way, every one dividing cleanly since sixteen is a power of two, so the size is identical at the default and six pages render to the pixel.
    3. The one- and two-pixel lengths stay px: they are hairline nudges that align an edge, not space that should scale, and rounding them into rem would move them by a rem's rounding rather than hold the line they were placed on.

III. Say directions the way a document understands them

Master to-do item 27. Independent of I and II, and can be worked in parallel or dropped without affecting them.

  1. A. Replace the paired .html-ltr/.html-rtl rules with logical properties. Across the stylesheets the mirror pairs are now single rules: where left-to-right said float: left and right-to-left said float: right, one rule says float: inline-start, and the same for margin-inline-start, padding-inline-end, inset-inline-start, text-align: start and the rest. 43 pairs collapsed in search.css and six more in mail.css and messages.css, the last two a directional border radius named with the logical corner properties (border-end-end-radius and its siblings); editor.css and frise.css had none.
    1. Each merged rule keeps both the .html-ltr and .html-rtl selectors in its list rather than dropping the prefix. Dropping it lowers the rule's specificity by a class and lets other rules win that did not before, which broke the layout when first tried; keeping both holds the specificity exactly while the logical property does the direction.
    2. Eight rules in search.css stay a scoped pair on purpose: three are not mirrors (a pagination cell with a thicker border and a centered label in one direction only; a small feed body inset differently each way; a side advert at a different top offset each way), and the machine-details arrow is a right-to-left glyph, content a logical property does not cover.
    3. The renderer here cannot check this: its old engine ignores logical properties outright, so a pixel diff is meaningless. Correctness was checked by resolving every logical property back to its physical value for a left-to-right reading and confirming each rule sets exactly what it set before, and by resolving the corner radii for both directions; right-to-left follows from the symmetric mapping and wants a browser with a right-to-left locale to confirm.

IV. Let a page answer the width it is given

Master to-do items 23 and 25. This is the point of the arc; I and II exist so that this can be done without the layout running off the side.

  1. A. Move the $_SERVER["MOBILE"] branches into media queries. 108 branch sites across 49 PHP files (the earlier 101/45 count was close). File by file, and each one is a decision about markup as much as CSS: a branch that emits different content is not the same as one that emits a different arrangement, and only the second can become a media query. Too large for one patch, so in substeps:
    1. Triage all 108 sites into content-diff (stays a PHP branch), arrangement-diff (becomes a media query), and logic-diff (neither — a real behavior choice); record the verdict per site so the conversion patches have a settled list. The full table is in IVA-mobile-branch-triage.html: of the 108, after moving the small-data column-hides to CSS per Chris's filesize rule, 52 are arrangement and become media queries in the next part, 36 are content and 18 are logic and stay PHP, and 2 are the viewport meta that IV.B removes. Done.
    2. Convert the arrangement-diff sites to media queries. Done in one pass against the settled triage. The conversion reuses the codebase's @media (max-width: 600px) block with four utilities: hide-narrow (a wide-only column or cell, always emitted and hidden below 600px, with the spacer-row colspan made the constant desktop count), show-narrow (a phone-only <br> or clear div, hidden by default and shown below 600px), center-wide (centered only above 600px), and a configure-lang-shift class for the one language-label offset. The column-hides across Manageclassifiers, Managelocales, Crawlstatus, Manageusers, Managegroups, Manageadvertisements, Searchsources, and Wiki resources became CSS, as did the clear divs, footer and result <br>s, the center swaps, and the configure nudge. Doing the work moved a set of sites out of arrangement: a branch that emits different markup — a <br> versus a </td><td> cell break, or a </tr><tr> row-break in the classifier and locale edit forms — cannot become a media query without first making the table one shape, so those stay PHP, and so does the pagination gap-versus-full-list. The triage records the final split: 35 arrangement (converted), 45 content, 25 logic, 2 viewport, 1 body-class. Done.
    3. Resolve the mixed sites — the ones that expressed a phone layout as different markup, a <br> or a </tr><tr> row-break where desktop used table cells. Each is split into the part that stays PHP and the part that becomes CSS by emitting one markup shape — the desktop cells — and adding a stack-narrow class whose cells become display: block below 600px, so the row stacks on a phone as the row-break used to. It covers the search form (SearchformHelper, its $item_sep now a constant cell break), the classifier and locale edit forms (EditclassifierElement, EditlocalesElement), and the group-users cell (ManagegroupsElement); the submit-row and status spacers are always emitted and hidden narrow. No MOBILE arrangement branch remains. Done.
  2. B. Emit the viewport meta tag always. The head emitted <meta name="viewport" content="width=device-width"> only when the request was sniffed as mobile, so a narrow screen that was not sniffed kept a desktop-width viewport and never triggered the new media queries. The gate is removed at WebLayout.php:100 and at the upgrade page's own head in VersionFunctions.php, so the viewport is always declared and the width, not the user agent, decides the layout. With A's media queries in place this is safe; the .mobile class rules still coexist until C retires them. Implemented, waiting on review.
  3. C. Retire what is left of the .mobile class. 290 selectors depend on it. Whatever survives A and B is either a real content decision or a rule that should have been a media query. In substeps:
    1. Inventory the .mobile selectors that remain after A and B and sort each into remove, fold into a media query, or keep as a real content marker. The full list is in IVC-mobile-selector-inventory.html: 246 rules across the four stylesheets (search.css, mail.css, frise.css, messages.css). 243 fold into the existing @media (max-width: 600px) block with the .mobile prefix dropped — the media query already means the narrow screen the class stood for — and 3 are removals where a non-mobile sibling in the same rule already applies. None are genuine content markers to keep. Implemented, waiting on review.
    2. Retire them by that inventory. Each .mobile X rule in search.css, mail.css, and messages.css was folded into that file's @media (max-width: 600px) block as X (mail.css and messages.css had no such block, so one was made), and the 3 redundant rules lost their .mobile selector. The layout's inline body.mobile and .mobile .top-landing-spacer styles became media queries too, and the <body> no longer carries the mobile class. A narrow-width render with the old class and the new media query matched. The content and logic branches keep reading the boolean $data['MOBILE'], which is a separate value set earlier and untouched. Implemented, waiting on review.

V. Themes and readers

Master to-do items 28 and 30. Both consume section I.

  1. A. Built-in light and dark modes. color-scheme: light dark, light-dark(), prefers-color-scheme. Nothing in the stylesheets mentions any of them today. This is not a theme in the getThemeNames() sense — those are site-specific CSS files that override Yioop's built-in styles (the V.D picker). A mode is the built-in styles themselves carrying a second value for each named color: both modes live in search.css, and the named colors from I.A get a light value and a dark value through light-dark() so the one stylesheet answers either. The toggle is got differently from a theme file — the browser's prefers-color-scheme with color-scheme on the root, not a linked override sheet. Needs I.A first, since a mode is a second set of values for named colors and there is nothing to name them against yet. A per-site switch gates it: a checkbox in Appearance, stored as a new domain profile field beside the existing color fields (SystemComponent's APPEARANCE_DOMAIN_FIELDS, written by ProfileModel::updateProfile, so no schema change), lets a site turn light/dark on or off globally; off leaves the site in its single built-in appearance as today. The field is given its default through nsconddefine in Config after Profile.php loads, the way the other profile-backed appearance fields are, so under the persistent WebSite server the toggle takes effect from p()'s cache on save rather than needing a restart on an install whose profile predated the field. Implemented, waiting on review.
  2. B. Accessibility-aware media features. A prefers-reduced-motion: reduce block in search.css now cuts the animation and the transitions this file sets to instant, the way mail.css already did for its one spinner, so a visitor who asks the system for less motion is not given the sliding panels or the spin. The aria-label hover tooltips became :focus-visible as well as :hover so a keyboard visitor gets the tooltip on tab focus while a mouse click no longer flashes it. WCAG 2.2 AA contrast was checked with the ratio formula on the landing and an admin page in both light and dark: every text node meets its 4.5:1 (or 3:1 for large text) threshold. Left for later, since AA already holds and each carries regression risk out of proportion to the gain here: prefers-contrast and gating the many :hover rules behind @media (hover: hover). Implemented, waiting on review.
  3. C. Add a checker to sit alongside the WAVE check. axe-core or Lighthouse. Settled as not worth doing: both re-report what the existing WAVE check and the manual WCAG 2.2 AA pass in B already cover, so a third checker would add maintenance without finding more.
  4. D. Let a visitor pick the site theme. Distinct from the light and dark modes of A: those are the built-in styles in two shades, while these named themes are whole CSS files that override the built-in styles. The site already owns a list of named themes — each a CSS file under the theme folder, listed by ProfileModel::getThemeNames() and today chosen per domain by an admin. This gives that choice to the visitor, in the two places a visitor already changes how the site looks and reads:
    1. A theme control in the landing page's hamburger menu, in the same Settings group as the language control (SearchmenuElement, beside the language element it already renders), and a matching one in a signed-in user's account settings next to the language row (ManageaccountElement, after the options_element_language_label row). Both offer the same <select> of getThemeNames(). Proposed.
    2. Persist the pick the way the neighboring settings already persist: in $_SESSION['THEME'] for a visitor who is not signed in, following the SAFE_SEARCH and OPEN_IN_TABS pattern in SearchController, and for a signed-in user in the per-user session blob the USER_SESSION table already holds (UserModel::getUserSession / setUserSession), where locale-style preferences already live, so it follows them between devices with no schema change. Proposed.
    3. Apply it through the seam the layout already has: WebLayout reads $data['PAGE_THEME_NAME'] to choose which theme stylesheet to link, falling back to the domain's AUXILIARY_CSS_NAME. Feeding the visitor's pick into PAGE_THEME_NAME (account value when signed in, else the session value, else the domain default) makes it the visitor's theme without a new emission path, and leaves a wiki page's own theme, which sets the same field, untouched. Proposed.

VI. The interface fixes Phase 4.5 handed over

These were tried in Phase 4.5 on 2026-07-17 and put back: fixing them there meant adding the .mobile branches item 23 removes and the in units item 29 removes. They are small, they are the reason this arc's order is what it is, and they are worth doing as soon as I and II land, since they are also the proof that I and II were done right.

  1. A. Make the resource toolbar one row of one height. The toolbar's controls came out at different heights and on different lines, and the sort and overflow dropdowns were pushed down by a fixed nudge to fake the alignment. Every control now takes the one named control height and centers on the middle line, so the row is a single clean height. The row stays end-aligned as it was; only the buttons were fixed. Implemented, waiting on review.
  2. B. Make the phone layout trigger in landscape, not only portrait. The compact layout was gated on max-width: 600px alone, which a phone held sideways never hit: it is wide (around 850px) but short, so it fell to the desktop layout and the desktop min-width ran off the side and clipped the page. The gate is now (max-width: 600px), (max-height: 480px), so a short landscape phone gets the compact layout while a landscape tablet, which stays well above 480px tall, keeps the desktop one. Measured, an 844×390 view went from overflowing at 1056px to fitting its 844px, and portrait, a landscape tablet, and the desktop are unchanged. The portrait toolbar can still fall to a second row when a long folder name and every control will not fit the width together; holding it to one row would mean shrinking the controls, so the wrap is left as it was.
  3. C. Give the Add Search dialog the shape of a dialog. It was built in wiki.js with a heavy sixteen-pixel green border, a fixed width fighting left and right insets, and a fixed height, so it looked unlike any other dialog and was cut off on a phone. It now takes the same shape as the standard light-content dialog: a rounded box with the sidebar-color border, centered, sized to fit its content and held within the viewport so it no longer runs off a phone. A poor line break in the related Word Filter Preferences title — the bracketed Factory Settings link splitting across two lines — is fixed in the same pass by keeping the bracketed link together on one line. Waiting on review.

Order and dependencies

I before II (there is nothing to convert lengths into until the scale is named), I and II before IV (the tag is only safe once the layout can flex), I before V (a theme is a second set of values for named colors), and I and II before VI. III stands alone and can go at any point. IV.B is the last thing in the arc.

Other work accomplished